深入探索 Paging 3.0: 分页加载来自网络和数据库的数据 | MAD Skills
使用 Room 创建 PagingSource
由于我们将要分页的数据源会来自本地而不是直接依赖 API,那么我们要做的第一件事便是更新 PagingSource。好消息是,我们要做的工作很少。是因为我前面提到的 "来自 Room 的小小帮助" 吗?事实上这里的帮助远不止于一点: 只需要在 Room 的 DAO 中为 PagingSource 添加声明,便可通过 DAO 获取 PagingSource!
@Dao
interface RepoDao {
@Query(
"SELECT * FROM repos WHERE " +
"name LIKE :queryString"
)
fun reposByName(queryString: String): PagingSource<Int, Repo>
}
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
…
val pagingSourceFactory = { database.reposDao().reposByName(dbQuery) }
@OptIn(ExperimentalPagingApi::class)
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = pagingSourceFactory,
remoteMediator = …,
).flow
}
RemoteMediator
RemoteMediator
https://developer.android.google.cn/reference/kotlin/androidx/paging/RemoteMediator
@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
…
) : RemoteMediator<Int, Repo>() {
…
}
让我们来仔细观察下 RemoteMediator 中的抽象方法。第一个方法是 initialize(),它是在所有加载开始前,RemoteMediator 调用的第一个方法,它的返回值为 InitializeAction。InitializeAction 可以是 LAUNCH_INITIAL_REFRESH,也可以是 SKIP_INITIAL_REFRESH。前者表示在调用 load() 方法时携带的加载类型为 refresh,后者意味着只有在 UI 明确发起请求时才会使用 RemoteMediator 执行刷新操作。在我们的用例中,由于仓库状态可能更新得颇为频繁,所以我们返回 LAUNCH_INITIAL_REFRESH。
override suspend fun initialize(): InitializeAction {
return InitializeAction.LAUNCH_INITIAL_REFRESH
}
接下来我们来看 load 方法。load 方法在 loadType 与 PagingState 所定义的边界处调用,加载类型可以是 refresh、append 或 prepend。这一方法负责获取数据,将其持久化在磁盘上并通知处理结果,其结果可以是 Error 或 Success。如果结果是 Error,加载状态将会反映这一结果,并可能重试加载。如果加载成功,需要通知 Pager 是否可以加载更多数据。
override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> …
LoadType.PREPEND -> …
LoadType.APPEND -> …
}
val apiQuery = query + IN_QUALIFIER
try {
val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)
val repos = apiResponse.items
val endOfPaginationReached = repos.isEmpty()
repoDatabase.withTransaction {
…
repoDatabase.reposDao().insertAll(repos)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (exception: IOException) {
return MediatorResult.Error(exception)
} catch (exception: HttpException) {
return MediatorResult.Error(exception)
}
}
LoadState、LoadStates 以及 CombinedLoadStates
由于分页是一系列异步事件,所以通过 UI 反映加载数据的当前状态十分重要。在分页操作中,Pager 的加载状态是通过 CombinedLoadStates 类型表示的。
顾名思义,这个类型是其他表示加载信息的类型的组合。这些类型包括:
Loading NotLoading Error
append prepend refresh
通常来讲,prepend 与 append 加载状态会用于响应额外的数据获取,而 refresh 加载状态则用来响应初始加载、刷新和重试。
由于 Pager 可能会从 PagingSource 或者 RemoteMediator 加载数据,所以 CombinedLoadStates 有两个 LoadState 字段。其中名为 source 的字段用于 PagingSource,而名为 mediator 的字段用于 RemoteMediator。
使用这些信息更新我们的 UI 就像从 PagingAdapter 暴露的 loadStateFlow 中获取数据一样简单。在我们的应用中,我们可以在第一次加载时使用这些信息显示一个加载指示器:
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
// 在刷新出错时显示重试头部,并且展示之前缓存的状态或者展示默认的 prepend 状态
header.loadState = loadState.mediator
?.refresh
?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
?: loadState.prepend
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// 显示空列表
emptyList.isVisible = isListEmpty
// 无论数据来自本地数据库还是远程数据,仅在刷新成功时显示列表。
list.isVisible = loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
// 在初始加载或刷新时显示加载指示器
progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
// 如果初始加载或刷新失败,显示重试状态
retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
}
}
回顾
在本文中,我们实现了以下功能:
使用数据库作为唯一可信数据源,并对数据进行分页;
使用 RemoteMediator 填充基于 Room 的 PagingSource;
使用来自 PagingAdapter 的 LoadStateFlow 更新带有进度条的 UI。
推荐阅读